feat(assertions): add CancellationToken overload to WaitsFor and Eventually#5938
Conversation
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 4 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Code Review — feat(assertions): add CancellationToken to WaitsFor and Eventually
Overall this is a well-designed, thoughtful addition. The edge cases are properly considered, the tests are comprehensive, and the public API snapshots are correctly updated for all four TFMs. A few items worth discussing:
1. Conditional linked-CTS construction — correct and good
using var linkedCts = _cancellationToken.CanBeCanceled
? CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken)
: new CancellationTokenSource();
linkedCts.CancelAfter(_timeout);Checking CanBeCanceled before linking is the right call — it avoids a heap-allocated CancellationTokenRegistration for every assertion invocation when no external token is supplied. Good optimisation that matches TUnit's performance-first principle.
2. Exception-filter ordering in Task.Delay catch — correct
catch (OperationCanceledException) when (_cancellationToken.IsCancellationRequested)
{
throw; // external
}
catch (OperationCanceledException)
{
break; // internal timeout
}The when filter correctly distinguishes external from internal cancellation. The one inherent race is if both fire at exactly the same instant: IsCancellationRequested will be true and we throw OCE rather than producing AssertionResult.Failed. This is arguably the right semantics (external cancellation takes priority), so the implementation is sound.
3. CancelAsync() in tests — verify .NET Framework build
WaitsFor_Honours_PreCancelled_Token_Before_First_Poll calls await cts.CancelAsync(), which was added in .NET 8. The public-API snapshots include Net4_7.verified.txt, which implies TUnit targets net472 somewhere. If TUnit.Assertions.Tests also compiles against net472, this test will fail to compile.
Suggestion: Either add #if NET8_0_OR_GREATER guards or replace with the synchronous cts.Cancel() (which is equivalent for a pre-cancel scenario and works on all TFMs).
4. Comment density exceeds project style guide
CLAUDE.md states "Default to writing no comments. Only add one when the WHY is non-obvious." Several comments in WaitsForAssertion.cs describe WHAT the code does rather than WHY it exists:
// Honour external cancellation between iterations: a pre-cancelled token, or a token
// cancelled during the previous Task.Delay, exits the loop without invoking the
// predicate. Internal-timeout cancellation falls through to AssertionResult.Failed.
_cancellationToken.ThrowIfCancellationRequested();The code is self-explanatory here. The one comment worth keeping is the CanBeCanceled block — the WHY (avoiding registration overhead) is genuinely non-obvious. The others could be removed.
5. Timing-sensitive test margins — probably fine, but note the risk
Tests like:
await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromSeconds(1));...with a 100ms cancel delay and a 30s internal timeout leave a comfortable 10× margin. In practice this should be robust, but under heavy CI load even 100ms operations can stretch. If these become flaky, widening the margin (e.g. < TimeSpan.FromSeconds(5)) would be the fix without changing the semantics.
6. Breaking change documentation is thorough
The PR description clearly enumerates affected call sites and the resolution path (shift to named arguments). Well-handled.
Summary
This is a solid, mergeable implementation. The two actionable items before merging are:
- Verify
CancelAsync()compiles on net472 (or switch tocts.Cancel()). - Trim the explanatory comments to align with the project's comment policy.
The design choices — parameter position, single defaulted overload, plain OperationCanceledException for external cancellation — are all well-reasoned and match the locked defaults from #5932.
…tually Adds a CancellationToken parameter to WaitsFor and Eventually so external cancellation can interrupt the polling loop before its internal timeout elapses. The token is honoured between iterations and during the polling Task.Delay; external cancellation propagates as OperationCanceledException while the internal timeout still produces the standard AssertionException. The parameter is defaulted to default(CancellationToken), so existing callers compile and behave unchanged. Closes thomhurst#5932.
Baseline regen for the Should public surface (DotNet8_0 / DotNet9_0 /
DotNet10_0): Eventually<TValue> and WaitFor<TValue> now carry the new
CancellationToken parameter, which had been missed in the initial commit and
was failing RunPublicAPITestsModule on all three modularpipeline matrix legs.
Test fixup: WaitsFor_Honours_PreCancelled_Token_Before_First_Poll switches
from cts.CancelAsync() to the synchronous cts.Cancel(). The async cancel is
.NET 8+ only and the test's purpose is verifying pre-cancelled state, for
which the two are equivalent.
Code-style fixup: trimmed WHAT-style explanatory comments in WaitsForAssertion
and the new tests per CLAUDE.md ("only when the WHY is non-obvious"). The
CanBeCanceled-skip-link comment stays; that one explains a non-obvious
optimisation.
Head branch was pushed to by a user without write access
da2e433 to
d3ef1d0
Compare
Description
Adds a
CancellationTokenparameter toWaitsForandEventually. The token is honoured between iterations of the polling loop, and the internalTask.Delayis wired through a linked cancellation source so both external cancellation and the internal timeout break the sleep. External cancellation propagates asOperationCanceledException; the internal timeout still produces the standardAssertionException.The parameter is defaulted (
CancellationToken cancellationToken = default), so callers using named arguments compile and behave unchanged.Implements the locked defaults proposed in #5932:
cancellationTokenThrowIfCancellationRequestedat the top of each iteration)OperationCanceledException(no TUnit wrapper)WaitsForandEventuallyonly (no change to other timeout-bearing assertions)Any of these defaults can be reversed in review.
Breaking changes
The new
cancellationTokenparameter is positioned betweenpollingIntervaland the[CallerArgumentExpression]parameters ofWaitsFor/Eventually, matching the convention used elsewhere inAssertionExtensions.cs(real parameters first, compiler-fill diagnostic parameters last).Impact on callers:
WaitsForAssertionTests.cs): unaffected.pollingInterval: unaffected.timeoutExpressionorpollingIntervalExpressiondirectly (overriding the compiler's auto-fill; non-standard but legal): CS0029 type mismatch at the 5th positional argument. Resolution is a one-line shift to named arguments.The functional contract for existing callers is preserved: when
cancellationTokenis not passed, the linked CTS short-circuits to a plain timeout-only source and behaviour matches the prior release.Related Issue
Fixes #5932
Type of Change
Checklist
Required
TUnit-Specific Requirements
TUnit.Core.SourceGenerator)TUnit.Engine)TUnit.Core.SourceGenerator.Testsand/orTUnit.PublicAPItests.received.txtfiles and accepted them as.verified.txt.verified.txtfiles[DynamicallyAccessedMembers]annotationsdotnet publish -p:PublishAot=trueTesting
dotnet test)Additional Notes
Regression tests added
Five new tests in
WaitsForAssertionTests.cs:WaitsFor_PropagatesExternalCancellation_Before_Internal_Timeout: external CT cancels after 100ms, predicate returns false continuously, internal timeout 30s. AssertsOperationCanceledExceptionand elapsed time under 1 second.WaitsFor_Throws_AssertionException_On_Internal_Timeout_When_Token_Not_Cancelled: CT created but never cancelled, internal timeout 200ms. AssertsAssertionException(the existing contract is preserved when CT is unused).WaitsFor_Honours_PreCancelled_Token_Before_First_Poll: pre-cancelled CT, would-pass predicate, internal timeout 5s. AssertsOperationCanceledExceptionand elapsed under 500ms (proves the loop exits on the first iteration).Eventually_PropagatesExternalCancellation_Before_Internal_Timeout: alias-symmetry test confirming the same cancellation contract holds forEventually(which forwards toWaitsFor).WaitsFor_Propagates_OCE_When_Predicate_Observes_Supplied_Token: predicate callscts.Token.ThrowIfCancellationRequested()after the token is cancelled mid-poll; verifies cancellation propagates within roughly the cancel delay plus one poll interval.Implementation
WaitsForAssertion<TValue>constructor takes the token and stores it as a field.CheckAsyncbuilds a linked CTS when the supplied token is cancellable, otherwise a plain timeout-only CTS. This avoids the linked-registration overhead when no external token is provided.cancellationToken.ThrowIfCancellationRequested()honours external cancel before each predicate invocation.Task.Delay(_pollingInterval, linkedCts.Token)is wrapped in twocatch (OperationCanceledException)clauses, the first using awhen (cancellationToken.IsCancellationRequested)filter to rethrow on external cancel and the second falling through to the existingAssertionResult.Failedpath on internal timeout.Public API snapshot updates
The
TUnit.PublicAPItest project'sTests.Assertions_Library_Has_No_API_Changessnapshots have been regenerated for the four supported TFMs (net472 / net8.0 / net9.0 / net10.0). Each delta is exactly three lines: theWaitsForAssertionconstructor, theWaitsForextension, and theEventuallyextension all gain the new defaultedCancellationTokenparameter.Local verification
TUnit.Assertions.Testssuite: 2111/2111 pass (existing 2106 + 5 new).TUnit.Assertionsonly; no source-generator emit is affected.